spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { ExternalLink } from 'lucide-react';5import { GraphLink } from '@/components/graph/graph-link';6import { PageHeader, Section, KV, Note } from '@/components/ui/section';7import { Badge, ClaimBadge } from '@/components/ui/badge';8import { EmptyState } from '@/components/ui/empty-state';9import { Freshness } from '@/components/ui/freshness';10import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';11import { JsonView } from '@/components/ui/json-view';12import { Pager } from '@/components/ui/pager';13import { EvidenceTable } from '@/components/data/evidence-table';14import { getVariantBySlug, variantAliases, clinicalSignificanceFor } from '@/lib/queries/genomics';15import { evidenceForVariant, evidenceForVariantCount, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';16import { loadProvenance, toInfo } from '@/lib/queries/provenance';17import { fmtInt, humanize } from '@/lib/format';18import { pageInfo } from '@/lib/pagination';19import { int, type SP } from '@/lib/search-params';2021export const revalidate = 3600;2223export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {24 const v = await getVariantBySlug((await params).slug);25 return v ? { title: `${v.gene_symbol ? `${v.gene_symbol} ` : ''}${v.name} — variant`, description: `Curated evidence by cancer and ClinVar interpretations for ${v.gene_symbol ?? ''} ${v.name}.`, alternates: { canonical: `/variant/${v.slug}` } } : { title: 'Variant' };26}2728const CLINVAR = { sourceSlug: 'clinvar', sourceName: 'ClinVar' };2930export default async function VariantPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {31 const { slug } = await params;32 const v = await getVariantBySlug(slug);33 if (!v) notFound();34 const sp = await searchParams;35 const evTotal = await evidenceForVariantCount(v.id);36 const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal);37 const [aliases, evidence, clinsig] = await Promise.all([variantAliases(v.id), evTotal ? evidenceForVariant(v.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), clinicalSignificanceFor(v.id)]);38 const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...clinsig.map((c) => c.provenance_id)]);39 // Evidence grouped by cancer (§50) — the query orders by cancer, so groups are contiguous within a page.40 const contexts = new Set(evidence.map((e) => e.cancer_id ?? `unmapped:${e.disease_name ?? 'unknown'}`)).size;41 const hrefFor = (p: number) => `/variant/${v.slug}${p > 1 ? `?evPage=${p}` : ''}#evidence`;42 const clinProv = clinsig[0] ? (toInfo(prov.get(clinsig[0].provenance_id)) ?? CLINVAR) : CLINVAR;4344 return (45 <article>46 <PageHeader kicker={`Variant${v.variant_type ? ` · ${humanize(v.variant_type)}` : ''}`} title={<>{v.gene_symbol ? <Link href={`/gene/${v.gene_symbol}`} className="ci-mono font-sans text-ink no-underline hover:text-accent">{v.gene_symbol}</Link> : null} {v.name}</>}>47 <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">48 <span className="ci-mono text-ink-3">{v.id}</span>49 <GraphLink type="variant" entityRef={v.slug} />50 {v.hgvs_p ? <span className="ci-mono">{v.hgvs_p}</span> : null}51 {v.hgvs_c ? <span className="ci-mono">{v.hgvs_c}</span> : null}52 {v.clinvar_variation_id ? (53 <a className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/clinvar/variation/${v.clinvar_variation_id}/`} target="_blank" rel="noopener noreferrer">54 ClinVar {v.clinvar_variation_id} <ExternalLink className="h-3 w-3" aria-hidden />55 </a>56 ) : null}57 {v.civic_variant_id ? (58 <a className="ci-link inline-flex items-center gap-1" href={`https://civicdb.org/variants/${v.civic_variant_id}/summary`} target="_blank" rel="noopener noreferrer">59 CIViC {v.civic_variant_id} <ExternalLink className="h-3 w-3" aria-hidden />60 </a>61 ) : null}62 {v.dbsnp_ids.map((r) => (63 <a key={r} className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/snp/${r}`} target="_blank" rel="noopener noreferrer">64 {r} <ExternalLink className="h-3 w-3" aria-hidden />65 </a>66 ))}67 </p>68 </PageHeader>6970 <div className="grid gap-8 lg:grid-cols-[1fr_320px]">71 <div className="space-y-8">72 <Section id="evidence" kicker="Curated evidence" title={`Evidence by cancer (${fmtInt(evTotal)} items)`} description={`Grouped by cancer context first, then therapy. The same variant can be sensitizing in one cancer and irrelevant in another — contexts are never merged. ${EVIDENCE_PAGE_SIZE} items per page; a cancer group may continue on the next page.`}>73 {evidence.length ? (74 <>75 <EvidenceTable76 items={evidence}77 prov={prov}78 groupBy="cancer"79 summary={80 <>81 Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items across {contexts} cancer context{contexts === 1 ? '' : 's'} on this page82 </>83 }84 />85 <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={hrefFor} label="Evidence pages" noun="evidence items" />86 </>87 ) : (88 <EmptyState compact>No curated evidence item references this variant yet.</EmptyState>89 )}90 </Section>9192 <Section id="clinvar" kicker="ClinVar" title={`Clinical significance (${fmtInt(clinsig.length)})`} description="ClinVar interpretations are shown as structured records — significance, review status, star rating, conditions — never flattened into one word.">93 {clinsig.length ? (94 <>95 <TableProvenance p={clinProv} claim={<ClaimBadge kind="curated" />}>96 {clinsig.length} interpretation{clinsig.length === 1 ? '' : 's'} · review status and star rating as assigned by ClinVar.97 </TableProvenance>98 <div className="ci-table-wrap">99 <table className="ci-table">100 <thead>101 <tr>102 <th scope="col">Variation</th>103 <th scope="col">Clinical significance</th>104 <th scope="col">Review status</th>105 <th scope="col" className="num">106 Stars107 </th>108 <th scope="col">Conditions</th>109 <th scope="col">Origin</th>110 <th scope="col" className="num">111 Submitters112 </th>113 <th scope="col">Last evaluated</th>114 <th scope="col">Source</th>115 </tr>116 </thead>117 <tbody>118 {clinsig.map((c) => (119 <tr key={c.id}>120 <td className="ci-mono">{c.clinvar_variation_id}</td>121 <td className="font-medium">{c.clinical_significance}</td>122 <td className="text-[12.5px]">{c.review_status ?? '—'}</td>123 <td className="num">{c.star_rating ?? '—'}</td>124 <td className="max-w-[300px] text-[12.5px]">{c.conditions.join('; ') || '—'}</td>125 <td className="text-[12.5px]">{c.origin_simple ?? '—'}</td>126 <td className="num">{c.number_submitters ?? '—'}</td>127 <td className="whitespace-nowrap text-[12.5px]">{c.last_evaluated ?? '—'}</td>128 <td>129 <SourceBadge compact title={null} p={toInfo(prov.get(c.provenance_id)) ?? CLINVAR} />130 </td>131 </tr>132 ))}133 </tbody>134 </table>135 </div>136 </>137 ) : (138 <EmptyState compact>No ClinVar interpretation attached to this variant.</EmptyState>139 )}140 </Section>141 </div>142143 <aside className="space-y-8">144 <Section id="coordinates" kicker="Nomenclature" title="Record" level={3}>145 <KV146 items={[147 { k: 'Gene', v: v.gene_symbol ? <Link className="ci-mono ci-link" href={`/gene/${v.gene_symbol}`}>{v.gene_symbol}</Link> : null },148 { k: 'Type', v: v.variant_type ? humanize(v.variant_type) : null },149 { k: 'HGVS g.', v: v.hgvs_g ? <span className="ci-mono break-all">{v.hgvs_g}</span> : null },150 { k: 'HGVS c.', v: v.hgvs_c ? <span className="ci-mono break-all">{v.hgvs_c}</span> : null },151 { k: 'HGVS p.', v: v.hgvs_p ? <span className="ci-mono break-all">{v.hgvs_p}</span> : null },152 { k: 'Assembly', v: v.assembly ? <Badge mono>{v.assembly}</Badge> : null },153 { k: 'Position', v: v.chromosome && v.start ? <span className="ci-mono">chr{v.chromosome}:{v.start}{v.end && v.end !== v.start ? `-${v.end}` : ''}</span> : null },154 { k: 'Ref / Alt', v: v.reference_bases || v.alternate_bases ? <span className="ci-mono">{v.reference_bases ?? '?'} → {v.alternate_bases ?? '?'}</span> : null },155 { k: 'Fusion partners', v: v.fusion_partners.length ? v.fusion_partners.join(' :: ') : null },156 { k: 'Aliases', v: aliases.length ? aliases.join(', ') : null },157 ]}158 />159 {v.coordinates.length ? (160 <details className="mt-2 text-[12.5px]">161 <summary className="ci-link">Per-assembly coordinates</summary>162 <JsonView data={v.coordinates} />163 </details>164 ) : null}165 <Freshness dataUpdatedAt={v.updated_at} />166 </Section>167 <Note tone="warn">Coordinates always carry their assembly; do not compare positions across GRCh37 and GRCh38. Evidence is not treatment guidance.</Note>168 </aside>169 </div>170 </article>171 );172}173